<?php
/**
 * Simple PHP Contact Form
 *
 * This file is part of the Simple PHP Contact Form which is subject to the New
 * BSD License {@see LICENSE} which you would be advised to read before using,
 * modifying or redistributing this software.
 *
 * PHP version 5.2
 *
 * @category  Simple-PHP-Contact-Form
 * @package   Application_Forms
 * @author    jah <jah@jahboite.co.uk>
 * @copyright 2010 jah <jah@jahboite.co.uk>
 * @license   New BSD License {@see LICENSE}
 * @version   SVN: $Id$
 * @link      http://code.google.com/p/simple-php-contact-form/
 */


/**
 * Form_Contact is the Contact Form class which extends Zend_Form.
 *
 * @category  Simple-PHP-Contact-Form
 * @package   Application_Forms
 * @author    jah <jah@jahboite.co.uk>
 * @copyright 2010 jah <jah@jahboite.co.uk>
 * @license   New BSD License {@see LICENSE}
 * @version   Release: @package_version@
 * @link      http://code.google.com/p/simple-php-contact-form/
 */
Class Form_Contact extends Zend_Form
{

    const CSRFCOOKIE = 'CSRFtoken';

    private $_tokenValue   = null;
    private $_tokenLength  = null;
    private $_tokenCharset = null;

    private $_formConfig   = null;
    private $_recipients   = null;

    private $_conditionallyUseCaptcha = true;


    /**
     * Constructor initialises the form with the supplied configration object.
     *
     * @param Zend_Config $config Form configuration object.
     *
     * @return null
     */
    public function __construct($config)
    {
        $this->_formConfig = $config;
        parent::__construct($this->_formConfig);
    }


    /**
     * Called by the constructor of Zend_Form after form.ini has been parsed.
     * form.ini should be used to set the configurable options of the contact
     * form, e.g. form action and method and defining form elements and
     * decorators.
     * This custom extension of Zend_From implements "double submit cookies"
     * {@link
     * http://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29_Prevention_Cheat_Sheet#Double_Submit_Cookies
     * } in order to provide some protection from CSRF. Whilst no server state
     * is maintained, this implementation should provide adequate protection as
     * the cookie value is entirely separate from any session details.
     *
     * @return null
     */
    public function init()
    {
        // Set the action and method properties for this form
        $this->setAction(PUBLIC_ROOT . 'send');
        $this->setMethod('post');

        // Get ESAPI encoder so we can use its constants
        ESAPI::getEncoder();
        $this->_tokenLength = 40;
        $this->_tokenCharset = Encoder::CHAR_ALPHANUMERICS;

        // Add CSRF Token element to the form
        $this->addElement(
            'hidden',
            'token',
            array(
                'order'   => 1500,
                'required' => true,
                'ignore' => true,
                'validators' => array(
                    array(
                        'CSRFToken',
                        true,
                        array(
                            $this->_tokenLength,
                            $this->_tokenCharset,
                            self::CSRFCOOKIE
                        )
                    )
                ),
                'decorators' => array('ViewHelper')
            )
        );
    }


    /**
     * Calls ESAPI to generate a random token which is then set as a hidden form
     * field and sent to the client as a cookie.
     *
     * @return null
     */
    public function setCSRFToken()
    {
        $this->_tokenValue = ESAPI::getRandomizer()->getRandomString(
            $this->_tokenLength, $this->_tokenCharset
        );
        // set the form element value
        $this->token->setValue($this->_tokenValue);
        // set the token cookie
        setcookie(
            self::CSRFCOOKIE,
            "{$this->_tokenValue}",
            '0',
            PUBLIC_ROOT . 'send',
            '',
            false,
            false
        );
    }


    /**
     * Generates an option in the recipient select element for each recipient
     * display name defined in the recpients Zend_Config object.
     * If there are less than two recipients the the select element will be
     * deleted from the form.
     *
     * @return null
     */
    private function _setRecipientSelector()
    {
        // If the list of recipients contains less than two recipients then we
        // remove the unecessary select element from the form.
        if (   sizeof($this->_recipients) < 2
            && $this->recipientMap instanceof Zend_Form_Element
        ) {
            $this->removeElement('recipientMap');
            $this->_conditionallyUseCaptcha = false;
            return;
        }

        // If the url contained a valid recipient name then that name will be
        // the selected option in the form.  The request parameter 'recipient'
        // will be set and it should exactly match a defined recipient.
        // Note that the user may have set the recipient parameter so validation
        // of that parameter must be done here.
        $fc = Zend_Controller_Front::getInstance();
        $validRecipient = $fc->getRequest()->getParam('recipient', null);
        if ($validRecipient !== null) {

            $recipientValidator = new Custom_Validate_ValidRecipient(
                $this->_recipients
            );
            if ($recipientValidator->isValid($validRecipient) === false) {
                $validRecipient = null;
            }

        }
        if ($validRecipient === null) {

            $this->_conditionallyUseCaptcha = false;

        }

        // Before we generate an option for each valid recipient, get any
        // existing options - these will be dummy options (there should ideally
        // be just one) that are not valid recipients but allow us to generate
        // a select element that does not have a pre-selected option and fails
        // to validate if one of the dummy values is selected so that the user
        // is reminded to select a recipient.
        $dummy = $this->recipientMap->getMultiOptions();

        // Now add a multi option for each recipient.  If validRecipient is set
        // then mark the corresponding element as the selected one (setValue()).
        $validRecipientLower = null;
        if ($validRecipient !== null) {

            $charEnc = mb_detect_encoding($validRecipient);
            $validRecipientLower = mb_strtolower($validRecipient, $charEnc);

        }

        // Get a whitespace removal filter
        $whitespace = new Zend_Filter_PregReplace(
            array('match' => '/ /', 'replace' => '')
        );

        foreach ($this->_recipients as $_ => $cfg) {

            foreach ($cfg as $key => $displayName) {

                if ($key !== 'display') {
                    continue;
                }

                $this->recipientMap->addMultiOption($displayName, $displayName);
                if ($validRecipientLower !== null) {
                    $charEnc = mb_detect_encoding($displayName);
                    $displayNameL = mb_strtolower($displayName, $charEnc);
                    $displayNameS = $whitespace->filter($displayNameL);

                    if (   $validRecipientLower === $displayNameL
                        || $validRecipientLower === $displayNameS
                    ) {
                        $this->recipientMap->setValue($displayName);
                    }
                }
            }
        }
        $o = array(
            'recipients' => $this->_recipients,
            'dummy' => $dummy,
        );
        $this->recipientMap->addValidator('validRecipient', false, $o);
        return;
    }


    /**
     * Sets the configuration object with the one supplied.
     *
     * @param Zend_Config $config Recipients Configuration object.
     *
     * @return null
     */
    public function setRecipientConfiguration($config)
    {
        if ($this->_recipients === null) {
            $this->_recipients = $config;
            $this->_setRecipientSelector();
        }
    }


    /**
     * Tests the token and recipient elements for values that have changed
     * since they were served.
     * Logs any validation errors and returns true if the token or recipient
     * fields were tampered with.
     * TODO extend to include checks for undefined fields in post...
     *
     * @return bool True if any of the invalid form fields were those which
     *              can only be changed by tampering, otherwise
     *              False.
     */
    public function detectedTamper()
    {
        $tamper = false;
        $msg = '';
        if ($this->token->hasErrors()) {
            $msg .= "[token:";
            foreach ($this->token->getMessages() as $errConst => $message) {
                    $msg .= "{$errConst}={$message};";
            }
            $msg .= "] ";
            $tamper = true;
        }
        if (   $this->recipientMap instanceof Zend_Form_Element
            && $this->recipientMap->hasErrors()
        ) {
            $messages = $this->recipientMap->getMessages();

            // Selecting a dummy recipient is not tampering so ignore such a
            // validation error
            if (! array_key_exists(Custom_Validate_ValidRecipient::DUMMY_RECIPIENT, $messages)) {
                $msg .= "[recipientMap:";
                foreach ($messages as $errConst => $message) {
                    $msg .= "{$errConst}={$message};";
                }
                $msg .= "] ";
                $tamper = true;
            }
        }
        if ($tamper !== false) {
            ESAPI::getAuditor('Form_Contact')->error(
                Auditor::SECURITY, false,
                "Validation Errors in hidden form fields: {$msg}"
            );
        }
        return $tamper;
    }


    /**
     * Add a reCaptcha element to the form assuming that:
     * o we have an ini file
     * o the 'use' options is not set to 'off'
     * o the 'use' option is set to 'on', and conditionallyUseCaptcha is not
     *   false.
     *
     * @return null
     */
    public function setCaptcha()
    {
        $bs = Zend_Controller_Front::getInstance()->getParam('bootstrap');
        $captchaConfigLoc = $bs->getOption('captchaconfigloc');

        // return if captcha should not be used.
        $captchaConfig = null;
        if (! empty($captchaConfigLoc)) {
            $captchaConfig = new Zend_Config_Ini(
                $captchaConfigLoc, APPLICATION_ENV, false
            );
            if ($captchaConfig instanceof Zend_Config) {
                if (  (strtolower($captchaConfig->recaptcha->use) == 'on'
                    && $this->_conditionallyUseCaptcha === false)
                    || strtolower($captchaConfig->recaptcha->use) == 'off'
                ) {
                    return;
                }
            } else {
                return;
            }
        } else {
            return;
        }

        // Recaptcha key pairs can be used at a single domain (and subdomains).
        // See if there are multiple key pairs (one per domain) and select the
        // correct pair. {@see matchDomianName}
        $keyConfig = null;
        foreach ($captchaConfig->recaptcha as $_ => $keypair) {
            if ($_ == 'use') {
                continue;
            }
            if (! isset($keypair->domain)) {
                ESAPI::getAuditor('Form_Contact')->error(
                    Auditor::SECURITY, false,
                    'Misconfiguration in captcha.ini - missing domain name from key pair set - Captcha Not Initialised!'
                );
                return;
            }
            if ($this->_matchDomainName($keypair->domain) === true) {
                $keyConfig = $keypair;
                break;
            }
        }

        if ($keyConfig instanceof Zend_Config === false) {
            ESAPI::getAuditor('Form_Contact')->error(
                Auditor::SECURITY, false,
                'Misconfiguration in captcha.ini - could not find key pairs for this host - Captcha Not Initialised!'
            );
            return;
        }

        $this->addElement(
            new Zend_Form_Element_Captcha('challenge',
                array(
                    'order'   => 750,
                    'captcha' => 'ReCaptcha',
                    'captchaOptions' => array(
                        'captcha' => 'ReCaptcha',
                        'service' => new Zend_Service_ReCaptcha(
                            $keyConfig->publicKey,
                            $keyConfig->privateKey
                        )
                    )
                )
            )
        );
    }


    /**
     * Helper which takes a domain name and checks whether the host header is
     * the same as, or a subdomain of, the supplied name.  If the host header
     * is a localhost IP or localhost name then it is considered a match
     * regardless of the supplied name.
     *
     * @param string $domain A domain name.
     *
     * @return bool True if the host header is the same name as the supplied
     *              name or a subdomain of it, otherwise
     *              False.
     */
    private function _matchDomainName($domain)
    {
        $charEnc = mb_detect_encoding($domain);
        if ($charEnc !== 'ASCII' && $charEnc !== 'UTF-8') {
            $domain = mb_convert_encoding($domain, 'UTF-8', $charEnc);
        }

        $host = ESAPI::getEncoder()->canonicalize($_SERVER['HTTP_HOST'], false);
        $charEnc = mb_detect_encoding($host);
        if ($charEnc !== 'ASCII' && $charEnc !== 'UTF-8') {
            $host = mb_convert_encoding($host, 'UTF-8', $charEnc);
        }

        $host = mb_strtolower($host, 'UTF-8');
        $domain = mb_strtolower($domain, 'UTF-8');

        $pos = mb_strpos($host, $domain, 0, 'UTF-8');
        if ($pos === false) {
            return false;
        }
        if ($pos !== 0 && mb_substr($host, $pos-1, 1, 'UTF-8') !== '.') {
            return false;
        }

        return true;
    }

}
